{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "bcb31876-4d8c-41ef-aa24-b8c78dfd5808",
   "metadata": {},
   "source": [
    "# Project - Stock Information AI Assistant\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b7bd1bd7-19d9-4c4b-bc4b-9bc9cca8bd0f",
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip install finnhub-python"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8b50bbe2-c0b1-49c3-9a5c-1ba7efa2bcb4",
   "metadata": {},
   "outputs": [],
   "source": [
    "# imports\n",
    "\n",
    "import os\n",
    "import json\n",
    "from dotenv import load_dotenv\n",
    "from openai import OpenAI\n",
    "import gradio as gr\n",
    "import finnhub\n",
    "from typing import Dict, List, Any, Optional\n",
    "from datetime import datetime"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ba0ddc1a-c775-4ed3-9531-ed0c5799e87f",
   "metadata": {},
   "outputs": [],
   "source": [
    "import logging\n",
    "\n",
    "# Configure root logger\n",
    "logging.basicConfig(\n",
    "    level=logging.INFO,              # Set level: DEBUG, INFO, WARNING, ERROR\n",
    "    format=\"%(asctime)s [%(levelname)s] %(message)s\", \n",
    "    force=True                       # Ensures reconfiguration if you rerun this cell\n",
    ")\n",
    "\n",
    "logger = logging.getLogger(__name__)  # Use a global logger object\n",
    "logger.info(\"Logger initialized!\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "747e8786-9da8-4342-b6c9-f5f69c2e22ae",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Initialization\n",
    "\n",
    "load_dotenv(override=True)\n",
    "\n",
    "openai_api_key = os.getenv('OPENAI_API_KEY')\n",
    "FINNHUB_API_KEY = os.getenv(\"FINNHUB_API_KEY\")\n",
    "\n",
    "if openai_api_key:\n",
    "    logger.info(f\"OpenAI API Key exists and begins {openai_api_key[:8]}\")\n",
    "else:\n",
    "    logger.error(\"OpenAI API Key not set\")\n",
    "\n",
    "if FINNHUB_API_KEY:\n",
    "    logger.info(f\"FINNHUB_API_KEY exists!\")\n",
    "else:\n",
    "    logger.error(\"OpenAI API Key not set\")\n",
    "    \n",
    "MODEL = \"gpt-4.1-mini\" # not using gpt-5-mini as openai doesn't let you stream responses till you are a verified organisation :(\n",
    "openai = OpenAI()\n",
    "finnhub_client = finnhub.Client(api_key=FINNHUB_API_KEY)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ee3aaa9a-5495-42fd-a382-803fbfa92eaf",
   "metadata": {},
   "outputs": [],
   "source": [
    "system_message = f\"\"\"\n",
    "You are \"TickerBot\" — a concise, factual, educational assistant specializing in U.S. stocks.  \n",
    "Your job: quickly and accurately explain stock and company information in plain English. NEVER give investment advice, buy/sell recommendations, or price predictions.\n",
    "\n",
    "## PRIVACY ABOUT IMPLEMENTATION\n",
    "- Do not reveal any internal implementation details to users. Never display or mention internal tool names, API names, developer notes, configured flags, date-range limits, or other system/developer constraints in user-facing replies.\n",
    "- All runtime/tool constraints and capability detection are internal. Present only user-facing capabilities in plain language.\n",
    "\n",
    "## USER-FACING CAPABILITIES\n",
    "- When asked \"What can you do?\", list only stock-relevant actions in plain language. Example reply:\n",
    "  \"I can look up tickers, show the latest quotes, provide key company financials and latest earnings details, summarize recent company or market headlines, and give a brief market overview.\"\n",
    "- Do not list internal utilities or developer tools as user-facing capabilities.\n",
    "\n",
    "## GENERAL PRINCIPLES\n",
    "- Answer only what was asked for. \n",
    "- Be brief, clear, and professional while still maintaining a warm tone. Use short paragraphs and one-line bullet explanations when requested.\n",
    "- Return only what the system provides; do not invent, infer, or extrapolate unavailable data.\n",
    "- Never offer or advertise any feature the environment does not actually support. Avoid offering attachments, direct downloads, or full-text article retrieval unless the system explicitly provides those outputs.\n",
    "\n",
    "## Behavior Rules\n",
    "- Stay professional and neutral at all times.  \n",
    "- Clarify only when user intent is ambiguous; never guess.  \n",
    "- Only disclose information the user explicitly requested.  \n",
    "- Never explain system limits (e.g., API ranges, date limits) ever.  \n",
    "- Summaries should be tight and relevant, not verbose.  \n",
    "\n",
    "## NEWS & HEADLINES\n",
    "- When interpreting date-related or temporal reasoning requests (e.g., “latest earnings,” “recent news,” “Q1 results”) Call `get_current_time` to determine the current date.\n",
    "- Present news/headlines in concise bullet lines when requested. Default recent-window behavior is internal; do not describe or expose internal default windows or limits to the user.\n",
    "- If the system only returns headlines/summaries, present those and do not offer to fetch full-text or additional ranges unless the user explicitly asks and the environment supports that action.\n",
    "\n",
    "## FOLLOW-UP & CLARIFYING QUESTIONS\n",
    "- If no matching stock symbol is found, ask the user to clarify the name or ticker. Mention you only support U.S. stocks. If they confirm the symbol but no data exists, state that no results were found.\n",
    "- Never append unsolicited menus, multi-choice lists, or repeated \"Would you like...\" prompts at the end of a normal reply.\n",
    "- Ask a single direct clarifying question only when strictly necessary to fulfill the user's request (for example: ambiguous company name or missing ticker). That single question must be the final line of the reply.\n",
    "- If the user's intent is clear, proceed and return results. Do not request confirmations or offer options unless required to complete the task.\n",
    "\n",
    "## MISSING-DATA / NOTE RULES\n",
    "- Do NOT call out missing/unavailable single fields unless:\n",
    "  1) the missing field was explicitly requested by the user; OR\n",
    "  2) multiple (>1) key metrics required to answer the user's request are unavailable and their absence materially prevents a useful answer.\n",
    "- If condition (1) or (2) applies, include at most one concise \"Note:\" line naming the specific missing field(s) and nothing else.\n",
    "- Otherwise omit any comment about single, non-central missing fields.\n",
    "\n",
    "## ERROR HANDLING\n",
    "- If a company/ticker cannot be found: \"I couldn't find that company on U.S. exchanges. Please provide the ticker or a clearer company name.\"\n",
    "- If partial results are returned: present available data and at most one short note: \"Some data wasn't available: <field1>, <field2>.\"\n",
    "- If you do not know the answer, say plainly: \"I don't have that information.\"\n",
    "\n",
    "## ADVICE / DISCLAIMER (CONDITIONAL)\n",
    "- Do NOT include an investment-advice disclaimer in normal greetings or factual replies.\n",
    "- Show a single-line disclaimer only when the user explicitly asks for recommendations/advice or uses clear advice-seeking language (e.g., \"should I buy\", \"recommend\", \"what should I do\", \"financial advice\").\n",
    "- Disclaimer text when triggered: \"I can't provide investment advice or buy/sell recommendations. I can provide facts and explain metrics.\"\n",
    "\n",
    "EXAMPLES (templates — follow exactly)\n",
    "\n",
    "1) Clear factual request (no unsolicited follow-up)\n",
    "User: \"What's AAPL last trade?\"\n",
    "Assistant:\n",
    "Last trade timestamp: 2025-08-28 20:00:00 UTC (U.S. market close — 4:00 PM ET)\n",
    "\n",
    "(—end: no menu, no offers—)\n",
    "\n",
    "2) Ambiguous request — single clarifying question only\n",
    "User: \"Show me Apple\"\n",
    "Assistant:\n",
    "I found two matches. Do you mean Apple Inc. (AAPL) or Apple Hospitality REIT (APLE)?\n",
    "\n",
    "(—end: single question final line—)\n",
    "\n",
    "3) News returned as headlines only (do not offer unsupported fetches)\n",
    "User: \"Recent NVDA news\"\n",
    "Assistant:\n",
    "- 2025-08-28: Headline A — short summary.\n",
    "- 2025-08-26: Headline B — short summary.\n",
    "(—end: do not offer full-article fetch or extended-range offers unless explicitly requested and supported—)\n",
    "\"\"\"\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fdf1a2b0-07be-47a0-9ce3-14d21b48c8f2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_current_time() -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Retrieve the current UTC time in ISO format with timezone.\n",
    "    Returns a dictionary for consistency with other tools.\n",
    "    \"\"\"\n",
    "    try:\n",
    "        current_time = datetime.utcnow().isoformat() + 'Z'\n",
    "        return {\n",
    "            \"success\": True,\n",
    "            \"current_time\": current_time\n",
    "        }\n",
    "    except Exception as e:\n",
    "        return {\"success\": False, \"error\": f\"Failed to get time: {str(e)[:100]}\"}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12d912fc-91fb-469e-9572-2876a099f5aa",
   "metadata": {},
   "outputs": [],
   "source": [
    "get_current_time_function = {\n",
    "    \"name\": \"get_current_time\",\n",
    "    \"description\": \"Get the current UTC time in ISO format (YYYY-MM-DDTHH:MM:SS.ssssssZ). Useful for temporal reasoning, date calculations, or setting time ranges for queries like news.\",\n",
    "    \"parameters\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {},  # No parameters needed\n",
    "        \"required\": []\n",
    "    }\n",
    "}\n",
    "get_current_time_tool = {\"type\": \"function\", \"function\": get_current_time_function}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "61a2a15d-b559-4844-b377-6bd5cb4949f6",
   "metadata": {},
   "outputs": [],
   "source": [
    "def validate_symbol(symbol: str) -> bool:\n",
    "    \"\"\"Validate stock symbol format\"\"\"\n",
    "    if not symbol or not isinstance(symbol, str):\n",
    "        return False\n",
    "    return symbol.isalnum() and 1 <= len(symbol) <= 5 and symbol.isupper()\n",
    "\n",
    "def search_symbol(query: str) -> Dict[str, Any]:\n",
    "    \"\"\"Search for stock symbol using Finnhub client\"\"\"\n",
    "    logger.info(f\"Tool search_symbol called for {query}\")\n",
    "    try:\n",
    "        if not query or len(query.strip()) < 1:\n",
    "            return {\"success\": False, \"error\": \"Invalid search query\"}\n",
    "        \n",
    "        query = query.strip()[:50]\n",
    "        result = finnhub_client.symbol_lookup(query)\n",
    "        logger.info(f\"Tool search_symbol {result}\")\n",
    "        \n",
    "        if result.get(\"result\") and len(result[\"result\"]) > 0:\n",
    "            first_result = result[\"result\"][0]\n",
    "            symbol = first_result.get(\"symbol\", \"\").upper()\n",
    "            \n",
    "            if validate_symbol(symbol):\n",
    "                return {\n",
    "                    \"success\": True,\n",
    "                    \"symbol\": symbol\n",
    "                }\n",
    "            else:\n",
    "                return {\"success\": False, \"error\": \"Invalid symbol format found\"}\n",
    "        else:\n",
    "            return {\"success\": False, \"error\": \"No matching US stocks found\"}\n",
    "            \n",
    "    except Exception as e:\n",
    "        return {\"success\": False, \"error\": f\"Symbol search failed: {str(e)[:100]}\"}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "173010e3-dfef-4611-8b68-d11256bd5fba",
   "metadata": {},
   "outputs": [],
   "source": [
    "search_symbol_function = {\n",
    "    \"name\": \"search_symbol\",\n",
    "    \"description\": \"Search for a stock symbol / ticker symbol based on company name or partial name\",\n",
    "    \"parameters\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\n",
    "            \"query\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"Company name or partial name to search for, extract only relevant name part and pass it here, keep this to less than 50 characters\"\n",
    "            }\n",
    "        },\n",
    "        \"required\": [\n",
    "            \"query\"\n",
    "        ]\n",
    "    }\n",
    "}\n",
    "\n",
    "search_symbol_tool = {\"type\": \"function\", \"function\": search_symbol_function}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "448bb4ce-8e86-4ceb-ab52-96bddfd33337",
   "metadata": {},
   "outputs": [],
   "source": [
    "def _format_big_number_from_millions(value_millions: Any) -> str:\n",
    "    \"\"\"\n",
    "    Finnhub returns some large metrics (marketCapitalization, enterpriseValue, revenueTTM)\n",
    "    in MILLIONS USD. Convert to full USD and format with M/B/T suffixes.\n",
    "    \"\"\"\n",
    "    if value_millions is None:\n",
    "        return \"Unavailable\"\n",
    "    try:\n",
    "        value = float(value_millions) * 1_000_000  # convert millions -> full USD\n",
    "    except (TypeError, ValueError):\n",
    "        return \"Unavailable\"\n",
    "\n",
    "    trillion = 1_000_000_000_000\n",
    "    billion = 1_000_000_000\n",
    "    million = 1_000_000\n",
    "\n",
    "    if value >= trillion:\n",
    "        return f\"{value / trillion:.2f}T USD\"\n",
    "    if value >= billion:\n",
    "        return f\"{value / billion:.2f}B USD\"\n",
    "    if value >= million:\n",
    "        return f\"{value / million:.2f}M USD\"\n",
    "    return f\"{value:.2f} USD\"\n",
    "\n",
    "\n",
    "def _safe_metric(metrics: Dict[str, Any], key: str) -> Any:\n",
    "    \"\"\"\n",
    "    Return metric value if present; otherwise \"Unavailable\".\n",
    "    We intentionally return the raw value for numeric metrics (no rounding/format)\n",
    "    except for the specially formatted big-number fields handled elsewhere.\n",
    "    \"\"\"\n",
    "    if metrics is None:\n",
    "        return \"Unavailable\"\n",
    "    val = metrics.get(key)\n",
    "    return val if val is not None else \"Unavailable\"\n",
    "\n",
    "\n",
    "def get_company_financials(symbol: str) -> Dict[str, Any]:\n",
    "    \"\"\"\n",
    "    Fetch and return a curated set of 'basic' financial metrics for `symbol`.\n",
    "    - Calls finnhub_client.company_basic_financials(symbol, 'all')\n",
    "    - Formats market cap, enterprise value, revenue (Finnhub returns these in millions)\n",
    "    - Returns success flag and readable keys\n",
    "    \"\"\"\n",
    "    logger.info(f\"Tool get_company_financials called for {symbol}\")\n",
    "    try:\n",
    "        if not symbol or not symbol.strip():\n",
    "            return {\"success\": False, \"error\": \"Invalid stock symbol\"}\n",
    "\n",
    "        symbol = symbol.strip().upper()\n",
    "\n",
    "        # --- API Call ---\n",
    "        financials_resp = finnhub_client.company_basic_financials(symbol, \"all\")\n",
    "\n",
    "        # Finnhub places primary values under \"metric\"\n",
    "        metrics = financials_resp.get(\"metric\", {})\n",
    "        if not metrics:\n",
    "            return {\"success\": False, \"error\": \"No financial metrics found\"}\n",
    "\n",
    "        # --- Build result using helpers ---\n",
    "        result = {\n",
    "            \"success\": True,\n",
    "            \"symbol\": symbol,\n",
    "            \"financials\": {\n",
    "                \"Market Cap\": _format_big_number_from_millions(metrics.get(\"marketCapitalization\")),\n",
    "                \"Enterprise Value\": _format_big_number_from_millions(metrics.get(\"enterpriseValue\")),\n",
    "                \"P/E Ratio (TTM)\": _safe_metric(metrics, \"peBasicExclExtraTTM\"),\n",
    "                \"Forward P/E\": _safe_metric(metrics, \"forwardPE\"),\n",
    "                \"Gross Margin (TTM)\": _safe_metric(metrics, \"grossMarginTTM\"),\n",
    "                \"Net Profit Margin (TTM)\": _safe_metric(metrics, \"netProfitMarginTTM\"),\n",
    "                \"EPS (TTM)\": _safe_metric(metrics, \"epsTTM\"),\n",
    "                \"EPS Growth (5Y)\": _safe_metric(metrics, \"epsGrowth5Y\"),\n",
    "                \"Dividend Yield (Indicated Annual)\": _safe_metric(metrics, \"dividendYieldIndicatedAnnual\"),\n",
    "                \"Current Ratio (Quarterly)\": _safe_metric(metrics, \"currentRatioQuarterly\"),\n",
    "                \"Debt/Equity (Long Term, Quarterly)\": _safe_metric(metrics, \"longTermDebt/equityQuarterly\"),\n",
    "                \"Beta\": _safe_metric(metrics, \"beta\"),\n",
    "                \"52-Week High\": _safe_metric(metrics, \"52WeekHigh\"),\n",
    "                \"52-Week Low\": _safe_metric(metrics, \"52WeekLow\"),\n",
    "            }\n",
    "        }\n",
    "\n",
    "        return result\n",
    "\n",
    "    except Exception as e:\n",
    "        # keep error message short but useful for debugging\n",
    "        return {\"success\": False, \"error\": f\"Failed to fetch metrics: {str(e)[:200]}\"}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9df7b74e-fec8-4e75-92a9-31acc75e6e97",
   "metadata": {},
   "outputs": [],
   "source": [
    "get_company_financials_function = {\n",
    "    \"name\": \"get_company_financials\",\n",
    "    \"description\": \"Fetch and return a curated set of basic financial metrics for a stock symbol. Calls Finnhub's company_basic_financials API, formats large numbers (market cap, enterprise value, revenue) in M/B/T USD, and shows metrics like P/E ratios, EPS, margins, dividend yield, debt/equity, beta, and 52-week range. Returns 'Unavailable' for missing values.\",\n",
    "    \"parameters\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\n",
    "            \"symbol\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"Stock ticker symbol to fetch metrics for. Example: 'AAPL' for Apple Inc.\"\n",
    "            }\n",
    "        },\n",
    "        \"required\": [\n",
    "            \"symbol\"\n",
    "        ]\n",
    "    }\n",
    "}\n",
    "\n",
    "\n",
    "get_company_financials_tool = {\"type\": \"function\", \"function\": get_company_financials_function}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cfeeb200-3f30-4855-82b9-cc8b2a950f80",
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_stock_quote(symbol: str) -> dict:\n",
    "    \"\"\"\n",
    "    Fetch the latest stock quote for a given ticker symbol using Finnhub's /quote endpoint.\n",
    "    Returns current price, daily high/low, open, previous close, percent change, and readable timestamp.\n",
    "    \"\"\"\n",
    "    logger.info(f\"Tool get_stock_quote called for {symbol}\")\n",
    "    try:\n",
    "        if not symbol or len(symbol.strip()) < 1:\n",
    "            return {\"success\": False, \"error\": \"Invalid symbol provided\"}\n",
    "        \n",
    "        symbol = symbol.strip().upper()\n",
    "        data = finnhub_client.quote(symbol)\n",
    "\n",
    "        if not data or \"c\" not in data:\n",
    "            return {\"success\": False, \"error\": \"No quote data found\"}\n",
    "        \n",
    "        # Convert epoch timestamp to ISO UTC if present\n",
    "        timestamp = data.get(\"t\")\n",
    "        if timestamp and isinstance(timestamp, (int, float)):\n",
    "            timestamp = datetime.utcfromtimestamp(timestamp).isoformat() + \"Z\"\n",
    "        else:\n",
    "            timestamp = \"Unavailable\"\n",
    "        \n",
    "        return {\n",
    "            \"success\": True,\n",
    "            \"symbol\": symbol,\n",
    "            \"current_price\": round(data.get(\"c\", 0), 2) if data.get(\"c\") is not None else \"Unavailable\",\n",
    "            \"change\": round(data.get(\"d\", 0), 2) if data.get(\"d\") is not None else \"Unavailable\",\n",
    "            \"percent_change\": f\"{round(data.get('dp', 0), 2)}%\" if data.get(\"dp\") is not None else \"Unavailable\",\n",
    "            \"high_price\": round(data.get(\"h\", 0), 2) if data.get(\"h\") is not None else \"Unavailable\",\n",
    "            \"low_price\": round(data.get(\"l\", 0), 2) if data.get(\"l\") is not None else \"Unavailable\",\n",
    "            \"open_price\": round(data.get(\"o\", 0), 2) if data.get(\"o\") is not None else \"Unavailable\",\n",
    "            \"previous_close\": round(data.get(\"pc\", 0), 2) if data.get(\"pc\") is not None else \"Unavailable\",\n",
    "            \"timestamp\": timestamp\n",
    "        }\n",
    "    except Exception as e:\n",
    "        return {\"success\": False, \"error\": f\"Quote retrieval failed: {str(e)[:100]}\"}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3724d92a-4515-4267-af6f-2c1ec2b6ed36",
   "metadata": {},
   "outputs": [],
   "source": [
    "get_stock_quote_function = {\n",
    "  \"name\": \"get_stock_quote\",\n",
    "  \"description\": \"Retrieve the latest stock quote for a given symbol, including current price, daily high/low, open, previous close, and percent change. Data is near real-time. Avoid constant polling; use websockets for streaming updates.\",\n",
    "  \"parameters\": {\n",
    "    \"type\": \"object\",\n",
    "    \"properties\": {\n",
    "      \"symbol\": {\n",
    "        \"type\": \"string\",\n",
    "        \"description\": \"Stock ticker symbol to fetch the latest quote for. Example: 'AAPL', 'MSFT'.\"\n",
    "      }\n",
    "    },\n",
    "    \"required\": [\"symbol\"]\n",
    "  }\n",
    "}\n",
    "\n",
    "get_stock_quote_tool = {\"type\": \"function\", \"function\": get_stock_quote_function}\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "62f5d477-6626-428f-b8eb-d763e736ef5b",
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_company_news(symbol: str, _from: str, to: str):\n",
    "    \"\"\"\n",
    "    Fetch the top latest company news for a stock symbol within a date range.\n",
    "    - Ensures the range does not exceed ~1 months (35 days).\n",
    "    - Best practice: Keep searches to a month or less to avoid too much data.\n",
    "\n",
    "    Args:\n",
    "        symbol (str): Stock ticker (e.g., \"AAPL\").\n",
    "        _from (str): Start date in YYYY-MM-DD format.\n",
    "        to (str): End date in YYYY-MM-DD format.\n",
    "\n",
    "    Returns:\n",
    "        list or dict: Cleaned news data or error message.\n",
    "    \"\"\"\n",
    "    # Validate date format\n",
    "    logger.info(f\"Tool get_company_news called for {symbol} from {_from} to {to}\")\n",
    "    try:\n",
    "        start_date = datetime.strptime(_from, \"%Y-%m-%d\")\n",
    "        end_date = datetime.strptime(to, \"%Y-%m-%d\")\n",
    "    except ValueError:\n",
    "        return {\"success\": False, \"error\": \"Invalid date format. Use YYYY-MM-DD.\"}\n",
    "\n",
    "    # Check date range\n",
    "    delta_days = (end_date - start_date).days\n",
    "    if delta_days > 35:\n",
    "        return {\n",
    "            \"success\": False, \n",
    "            \"error\": f\"Date range too large ({delta_days} days). \"\n",
    "                     \"Please use a range of 1 months or less.\"\n",
    "        }\n",
    "\n",
    "    # Fetch data\n",
    "    try:\n",
    "        news = finnhub_client.company_news(symbol, _from=_from, to=to)\n",
    "    except Exception as e:\n",
    "        return {\"success\": False, \"error\": str(e)}\n",
    "\n",
    "    # Do not want to report just the latest news in the time period\n",
    "    if len(news) <= 10:\n",
    "    # If 10 or fewer articles, take all\n",
    "        selected_news = news\n",
    "    else:\n",
    "        # Take first 5 (oldest) and last 5 (newest)\n",
    "        selected_news = news[:5] + news[-5:]\n",
    "\n",
    "    # Clean & transform objects\n",
    "    cleaned_news = []\n",
    "    for article in selected_news:\n",
    "        cleaned_news.append({\n",
    "            \"summary\": article.get(\"summary\"),\n",
    "            \"source\": article.get(\"source\"),\n",
    "            \"published_at\": datetime.utcfromtimestamp(article[\"datetime\"]).strftime(\"%Y-%m-%d %H:%M:%S UTC\"),\n",
    "            \"related\": article.get(\"related\")\n",
    "        })\n",
    "\n",
    "    return {\"success\": True, \"news\": cleaned_news}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5150ecb6-e3f1-46dc-94fa-2a9abe5165f6",
   "metadata": {},
   "outputs": [],
   "source": [
    "get_company_news_function = {\n",
    "    \"name\": \"get_company_news\",\n",
    "    \"description\": \"Fetch the top most recent company news articles for a given stock symbol. ⚠️ Avoid querying more than a 1-month range at a time as it may return too much data. Only tells news about company within last 1 year. An error is returned if the requested time range exceeds 1 month.\",\n",
    "    \"parameters\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\n",
    "            \"symbol\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"Stock ticker symbol, e.g., 'AAPL'.\"\n",
    "            },\n",
    "            \"_from\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"Start date in YYYY-MM-DD format. Ensure it is not more than 1 year ago from today. Ensure it is before or equal to the date in to.\"\n",
    "            },\n",
    "            \"to\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"End date in YYYY-MM-DD format. Ensure it is not more than 1 year ago. Ensure it is after or equal to the date in from.\"\n",
    "            }\n",
    "        },\n",
    "        \"required\": [\n",
    "            \"symbol\",\n",
    "            \"_from\",\n",
    "            \"to\"\n",
    "        ]\n",
    "    }\n",
    "}\n",
    "\n",
    "get_company_news_tool = {\"type\": \"function\", \"function\": get_company_news_function}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26dd7375-626f-4235-b4a2-f1926f62cc5e",
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_market_news(category: str = \"general\"):\n",
    "    \"\"\"\n",
    "    Fetch the latest market news for a given category.\n",
    "\n",
    "    Args:\n",
    "        category (str): News category. One of [\"general\", \"forex\", \"crypto\", \"merger\"].\n",
    "\n",
    "    Returns:\n",
    "        list or dict: A cleaned list of news articles or error message.\n",
    "    \"\"\"\n",
    "    logger.info(f\"Tool get_market_news called for category '{category}'\")\n",
    "\n",
    "    try:\n",
    "        news = finnhub_client.general_news(category)\n",
    "    except Exception as e:\n",
    "        logger.error(f\"Tool get_market_news API call failed!\")\n",
    "        return {\"success\": False, \"error\": str(e)}\n",
    "\n",
    "    # Do not want to report just the latest news in the time period\n",
    "    if len(news) <= 10:\n",
    "    # If 10 or fewer articles, take all\n",
    "        selected_news = news\n",
    "    else:\n",
    "        # Take first 5 (oldest) and last 5 (newest)\n",
    "        selected_news = news[:5] + news[-5:]\n",
    "\n",
    "    # Clean & transform objects\n",
    "    cleaned_news = []\n",
    "    for article in selected_news:\n",
    "        cleaned_news.append({\n",
    "            \"headline\": article.get(\"headline\"),\n",
    "            \"summary\": article.get(\"summary\"),\n",
    "            \"source\": article.get(\"source\"),\n",
    "            \"category\": article.get(\"category\"),\n",
    "            \"related\": article.get(\"related\")\n",
    "        })\n",
    "\n",
    "    return {\"success\": True, \"news\": cleaned_news}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5bd1aa28-119c-4c7a-bdc0-161a582ab1cc",
   "metadata": {},
   "outputs": [],
   "source": [
    "get_market_news_function = {\n",
    "  \"name\": \"get_market_news\",\n",
    "  \"description\": \"Fetch the latest market news by category. Returns the top 10 news articles with headline, summary, source, category, published time (UTC), and URLs. Categories: general, forex, crypto, merger. Use this to quickly get relevant financial news.\",\n",
    "  \"parameters\": {\n",
    "    \"type\": \"object\",\n",
    "    \"properties\": {\n",
    "      \"category\": {\n",
    "        \"type\": \"string\",\n",
    "        \"description\": \"News category to fetch. One of: general, forex, crypto, merger.\"\n",
    "      }\n",
    "    },\n",
    "    \"required\": [\"category\"]\n",
    "  }\n",
    "}\n",
    "\n",
    "get_market_news_tool = {\"type\": \"function\", \"function\": get_market_news_function}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fbe8ef6c-2d88-43a2-94dc-70b507fe9cd2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_earnings_calendar(symbol: str = \"\", _from: str = \"\", to: str = \"\"):\n",
    "    \"\"\"\n",
    "    Fetch LATEST earnings calendar data for a stock symbol within a date range.\n",
    "    - End date must be within the last month. (Free tier only allows last 1 month data)\n",
    "    - Shows historical and upcoming earnings releases with EPS and revenue data.\n",
    "    Args:\n",
    "        symbol (str): Stock ticker (e.g., \"AAPL\"). Leave empty for all companies.\n",
    "        _from (str): Start date in YYYY-MM-DD format.\n",
    "        to (str): End date in YYYY-MM-DD format.\n",
    "    Returns:\n",
    "        list or dict: Cleaned earnings calendar data or error message.\n",
    "    \"\"\"\n",
    "    logger.info(f\"Tool get_earnings_calendar called for {symbol or 'all symbols'} from {_from} to {to}\")\n",
    "    \n",
    "    # Validate date format if provided\n",
    "    if _from or to:\n",
    "        try:\n",
    "            start_date = datetime.strptime(_from, \"%Y-%m-%d\") if _from else None\n",
    "            end_date = datetime.strptime(to, \"%Y-%m-%d\") if to else None\n",
    "            \n",
    "            # Check date range if both dates provided\n",
    "            # Check if end_date is within 1 month (≈30 days) of today\n",
    "            if end_date:\n",
    "                today = datetime.utcnow()\n",
    "                if (today - end_date).days > 30:\n",
    "                    return {\n",
    "                        \"success\": False,\n",
    "                        \"error\": \"End date must be within the last month.\"\n",
    "                    }\n",
    "        except ValueError:\n",
    "            return {\"success\": False, \"error\": \"Invalid date format. Use YYYY-MM-DD.\"}\n",
    "    \n",
    "    # Fetch earnings calendar data\n",
    "    try:\n",
    "        earnings_data = finnhub_client.earnings_calendar(_from=_from, to=to, symbol=symbol, international=False)\n",
    "    except Exception as e:\n",
    "        logger.error(f\"Error fetching earnings calendar: {e}\")\n",
    "        return {\"success\": False, \"error\": str(e)}\n",
    "    \n",
    "    # Check if data exists\n",
    "    if not earnings_data or \"earningsCalendar\" not in earnings_data:\n",
    "        return {\"success\": False, \"error\": \"No earnings data available for the specified criteria.\"}\n",
    "    \n",
    "    earnings_list = earnings_data[\"earningsCalendar\"]\n",
    "    \n",
    "    if not earnings_list:\n",
    "        return {\"success\": True, \"earnings\": [], \"message\": \"No earnings releases found for the specified period.\"}\n",
    "    \n",
    "    # Clean & transform earnings data\n",
    "    cleaned_earnings = []\n",
    "    for earning in earnings_list:\n",
    "        # Format hour description\n",
    "        hour_map = {\n",
    "            \"bmo\": \"Before Market Open\",\n",
    "            \"amc\": \"After Market Close\", \n",
    "            \"dmh\": \"During Market Hours\"\n",
    "        }\n",
    "        \n",
    "        cleaned_earnings.append({\n",
    "            \"symbol\": earning.get(\"symbol\"),\n",
    "            \"date\": earning.get(\"date\"),\n",
    "            \"quarter\": f\"Q{earning.get('quarter')} {earning.get('year')}\",\n",
    "            \"announcement_time\": hour_map.get(earning.get(\"hour\", \"\"), earning.get(\"hour\", \"Unknown\")),\n",
    "            \"eps_actual\": earning.get(\"epsActual\"),\n",
    "            \"eps_estimate\": earning.get(\"epsEstimate\"),\n",
    "            \"revenue_actual\": earning.get(\"revenueActual\"),\n",
    "            \"revenue_estimate\": earning.get(\"revenueEstimate\")\n",
    "        })\n",
    "    \n",
    "    return {\"success\": True, \"earnings\": cleaned_earnings}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9eaeae75-d68f-4160-a26e-c13e40cf756b",
   "metadata": {},
   "outputs": [],
   "source": [
    "get_earnings_calendar_function = {\n",
    "    \"name\": \"get_earnings_calendar\",\n",
    "    \"description\": \"Fetch latest earnings calendar showing historical and upcoming earnings releases for companies. Shows EPS and revenue estimates vs actuals. End date must be within the last month.\",\n",
    "    \"parameters\": {\n",
    "        \"type\": \"object\",\n",
    "        \"properties\": {\n",
    "            \"symbol\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"Stock ticker symbol, e.g., 'AAPL'. Leave empty to get earnings for all companies in the date range.\"\n",
    "            },\n",
    "            \"_from\": {\n",
    "                \"type\": \"string\", \n",
    "                \"description\": \"Start date in YYYY-MM-DD format. Ensure it is not more than 1 year ago from today. Ensure it is before or equal to the date in to.\"\n",
    "            },\n",
    "            \"to\": {\n",
    "                \"type\": \"string\",\n",
    "                \"description\": \"End date in YYYY-MM-DD format. Ensure it is not more than 1 year ago. Ensure it is after or equal to the date in from. To date must be within the last month.\"\n",
    "            }\n",
    "        },\n",
    "        \"required\": [\n",
    "            \"_from\",\n",
    "            \"to\"\n",
    "        ]\n",
    "    }\n",
    "}\n",
    "\n",
    "get_earnings_calendar_tool = {\"type\": \"function\", \"function\": get_earnings_calendar_function}"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "bdca8679-935f-4e7f-97e6-e71a4d4f228c",
   "metadata": {},
   "outputs": [],
   "source": [
    "# List of tools:\n",
    "tools = [search_symbol_tool, get_company_financials_tool, get_stock_quote_tool, get_company_news_tool, get_market_news_tool, get_current_time_tool, get_earnings_calendar_tool]\n",
    "tool_functions = {\n",
    "    \"search_symbol\": search_symbol,\n",
    "    \"get_company_financials\": get_company_financials,\n",
    "    \"get_stock_quote\": get_stock_quote,\n",
    "    \"get_company_news\": get_company_news,\n",
    "    \"get_market_news\": get_market_news,\n",
    "    \"get_earnings_calendar\": get_earnings_calendar,\n",
    "    \"get_current_time\": get_current_time\n",
    "}"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c3d3554f-b4e3-4ce7-af6f-68faa6dd2340",
   "metadata": {},
   "source": [
    "## Getting OpenAI to use our Tool\n",
    "\n",
    "There's some fiddly stuff to allow OpenAI \"to call our tool\"\n",
    "\n",
    "What we actually do is give the LLM the opportunity to inform us that it wants us to run the tool.\n",
    "\n",
    "Here's how the new chat function looks:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "86f76f57-76c4-4dc7-94a8-cfe7816a39f1",
   "metadata": {},
   "outputs": [],
   "source": [
    "def execute_tool_call(tool_call):\n",
    "    func_name = tool_call.function.name\n",
    "    args = json.loads(tool_call.function.arguments)\n",
    "\n",
    "    logger.info(f\"Executing tool: {func_name} with args: {args}\")\n",
    "\n",
    "    func = tool_functions.get(func_name)\n",
    "    if not func:\n",
    "        result = {\"error\": f\"Function '{func_name}' not found\"}\n",
    "    else:\n",
    "        try:\n",
    "            result = func(**args)\n",
    "        except Exception as e:\n",
    "            logger.exception(f\"Error executing {func_name}\")\n",
    "            result = {\"error\": str(e)}\n",
    "\n",
    "    return {\n",
    "        \"role\": \"tool\",\n",
    "        \"tool_call_id\": tool_call.id,\n",
    "        \"content\": json.dumps(result)\n",
    "    }"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ce9b0744-9c78-408d-b9df-9f6fd9ed78cf",
   "metadata": {},
   "outputs": [],
   "source": [
    "def chat(message, history):\n",
    "    messages = [{\"role\": \"system\", \"content\": system_message}] + history + [{\"role\": \"user\", \"content\": message}]\n",
    "\n",
    "    # Skip the first system message\n",
    "    to_log = messages[1:]\n",
    "\n",
    "    # Print each dict on its own line\n",
    "    logger.info(\"\\nMessages:\\n\" + \"\\n\".join(str(m) for m in to_log) + \"\\n\")\n",
    "\n",
    "    while True:\n",
    "        response = openai.chat.completions.create(\n",
    "            model=MODEL, \n",
    "            messages=messages, \n",
    "            tools=tools,\n",
    "            stream=True\n",
    "        )\n",
    "        \n",
    "        content = \"\"\n",
    "        tool_calls = []\n",
    "        finish_reason = None\n",
    "        \n",
    "        # Process the stream\n",
    "        for chunk in response:\n",
    "            choice = chunk.choices[0]\n",
    "            finish_reason = choice.finish_reason\n",
    "            \n",
    "            # Stream content\n",
    "            if choice.delta.content:\n",
    "                content += choice.delta.content\n",
    "                yield content\n",
    "            \n",
    "            # Collect tool calls\n",
    "            if choice.delta.tool_calls:\n",
    "                for tc_delta in choice.delta.tool_calls:\n",
    "                    # Extend tool_calls list if needed\n",
    "                    while len(tool_calls) <= tc_delta.index:\n",
    "                        tool_calls.append({\n",
    "                            \"id\": \"\",\n",
    "                            \"function\": {\"name\": \"\", \"arguments\": \"\"}\n",
    "                        })\n",
    "                    \n",
    "                    tc = tool_calls[tc_delta.index]\n",
    "                    if tc_delta.id:\n",
    "                        tc[\"id\"] = tc_delta.id\n",
    "                    if tc_delta.function:\n",
    "                        if tc_delta.function.name:\n",
    "                            tc[\"function\"][\"name\"] = tc_delta.function.name\n",
    "                        if tc_delta.function.arguments:\n",
    "                            tc[\"function\"][\"arguments\"] += tc_delta.function.arguments\n",
    "        \n",
    "        # If no tool calls, we're done\n",
    "        if finish_reason != \"tool_calls\":\n",
    "            return content\n",
    "        \n",
    "        # Execute tools\n",
    "        ai_message = {\n",
    "            \"role\": \"assistant\", \n",
    "            \"content\": content,\n",
    "            \"tool_calls\": [\n",
    "                {\n",
    "                    \"id\": tc[\"id\"],\n",
    "                    \"type\": \"function\",\n",
    "                    \"function\": tc[\"function\"]\n",
    "                } for tc in tool_calls\n",
    "            ]\n",
    "        }\n",
    "        \n",
    "        tool_responses = []\n",
    "        for tool_call in ai_message[\"tool_calls\"]:\n",
    "            # Convert dict back to object for your existing function\n",
    "            class ToolCall:\n",
    "                def __init__(self, tc_dict):\n",
    "                    self.id = tc_dict[\"id\"]\n",
    "                    self.function = type('obj', (object,), tc_dict[\"function\"])\n",
    "            \n",
    "            tool_responses.append(execute_tool_call(ToolCall(tool_call)))\n",
    "        \n",
    "        messages.append(ai_message)\n",
    "        messages.extend(tool_responses)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f4be8a71-b19e-4c2f-80df-f59ff2661f14",
   "metadata": {},
   "outputs": [],
   "source": [
    "gr.ChatInterface(fn=chat, type=\"messages\", title=\"TickerBot\", description=\"Ask about stock prices, company financials and market news!\").launch(share=True)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5c014d6f-820d-4d58-8527-7d703aad3399",
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "40c77d61-3e90-4708-b360-fb58b4211e9b",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
